| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- "use client";
- import { useEffect, useRef, useCallback, useState } from "react";
- import { useSession } from "next-auth/react";
- import { useParams, redirect, useRouter } from "next/navigation";
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
- import { Button } from "@/components/ui/button";
- import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- } from "@/components/ui/dialog";
- import { Loader2, Video, AlertTriangle, FileText, Clock, XCircle } from "lucide-react";
- import { ConsultationNotes } from "@/components/appointments/ConsultationNotes";
- import RecordsModal from "@/components/records/RecordsModal";
- import type { Record as MedicalRecord } from "@/components/records/types";
- import type { Appointment } from "@/types/appointments";
- import { canJoinMeeting, getAppointmentTimeStatus } from "@/utils/appointments";
- interface JitsiMeetExternalAPI {
- dispose: () => void;
- addEventListener: (event: string, handler: () => void) => void;
- }
- declare global {
- interface Window {
- JitsiMeetExternalAPI: new (domain: string, options: Record<string, unknown>) => JitsiMeetExternalAPI;
- }
- }
- export default function MeetPage() {
- const router = useRouter();
- const { data: session, status } = useSession();
- const params = useParams();
- const jitsiContainer = useRef<HTMLDivElement>(null);
- const jitsiApi = useRef<JitsiMeetExternalAPI | null>(null);
- const isInitialized = useRef(false);
- const isLeavingIntentionally = useRef(false);
- const [showExitDialog, setShowExitDialog] = useState(false);
- const [showRecordsModal, setShowRecordsModal] = useState(false);
- const [appointment, setAppointment] = useState<Appointment | null>(null);
- const [loading, setLoading] = useState(true);
- const [accessDenied, setAccessDenied] = useState(false);
- const [denialReason, setDenialReason] = useState("");
- const [jitsiToken, setJitsiToken] = useState<string | null>(null);
- const [jitsiDomain, setJitsiDomain] = useState<string>("");
- const [jitsiRoomName, setJitsiRoomName] = useState<string>("");
- const [useJWT, setUseJWT] = useState<boolean>(false);
- // Cargar información del appointment y JWT token
- useEffect(() => {
- const loadAppointment = async () => {
- try {
- const response = await fetch(`/api/appointments/${params.id}`);
- if (response.ok) {
- const data = await response.json();
- setAppointment(data);
- // Validar acceso por tiempo
- const timeCheck = canJoinMeeting(data.fechaSolicitada);
- if (!timeCheck.canJoin) {
- setAccessDenied(true);
- setDenialReason(timeCheck.reason || "No puedes acceder a esta videollamada");
- setLoading(false);
- return;
- }
- // Validar que la cita esté aprobada
- if (data.estado !== "APROBADA" && data.estado !== "COMPLETADA") {
- setAccessDenied(true);
- setDenialReason("Esta cita no está aprobada");
- setLoading(false);
- return;
- }
- // Obtener JWT token para Jitsi
- const tokenResponse = await fetch(`/api/appointments/${params.id}/jitsi-token`);
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- setJitsiToken(tokenData.token || null);
- setJitsiDomain(tokenData.domain || "");
- setJitsiRoomName(tokenData.roomName || `appointment-${params.id}`);
- setUseJWT(tokenData.useJWT || false);
- } else {
- console.warn("No se pudo obtener JWT token, usando configuración por defecto");
- setJitsiRoomName(`appointment-${params.id}`);
- // Si falla, intentar obtener el dominio del response aunque sea error
- const errorData = await tokenResponse.json().catch(() => ({}));
- if (errorData.domain) {
- setJitsiDomain(errorData.domain);
- }
- }
- } else {
- setAccessDenied(true);
- setDenialReason("No se pudo cargar la información de la cita");
- }
- } catch (error) {
- console.error("Error loading appointment:", error);
- setAccessDenied(true);
- setDenialReason("Error al cargar la cita");
- } finally {
- setLoading(false);
- }
- };
- if (params.id) {
- loadAppointment();
- }
- }, [params.id]);
- const handleCopyContent = (content: string) => {
- navigator.clipboard.writeText(content);
- // TODO: Add notification
- };
- const handleDownloadReport = (record: MedicalRecord) => {
- const blob = new Blob([record.content], { type: "text/plain" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `reporte-medico-${record.id.slice(-8)}-${new Date().toISOString().split("T")[0]}.txt`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- // TODO: Add notification
- };
- const handleGeneratePDF = async (_record: MedicalRecord) => {
- // TODO: Implement PDF generation
- };
- const initJitsi = useCallback(() => {
- if (!jitsiContainer.current || !session || isInitialized.current || !jitsiRoomName) return;
- isInitialized.current = true;
- const options: Record<string, unknown> = {
- roomName: jitsiRoomName,
- width: "100%",
- height: 600,
- parentNode: jitsiContainer.current,
- configOverwrite: {
- startWithAudioMuted: false,
- startWithVideoMuted: false,
- prejoinPageEnabled: false,
- },
- interfaceConfigOverwrite: {
- TOOLBAR_BUTTONS: [
- "microphone",
- "camera",
- "closedcaptions",
- "desktop",
- "fullscreen",
- "fodeviceselection",
- "hangup",
- "chat",
- "settings",
- "videoquality",
- "filmstrip",
- "tileview",
- ],
- SHOW_JITSI_WATERMARK: false,
- SHOW_WATERMARK_FOR_GUESTS: false,
- },
- userInfo: {
- displayName: `${session.user?.name || "Usuario"} ${session.user?.lastname || ""}`.trim(),
- email: session.user?.email || undefined,
- },
- };
- // Si se usa JWT, agregar el token
- if (useJWT && jitsiToken) {
- options.jwt = jitsiToken;
- }
- jitsiApi.current = new window.JitsiMeetExternalAPI(jitsiDomain, options);
- // Event listeners - Solo redirigir si el usuario salió desde Jitsi directamente
- jitsiApi.current.addEventListener("videoConferenceLeft", () => {
- // Dar un pequeño delay para que el beforeunload se procese
- setTimeout(() => {
- if (isLeavingIntentionally.current) {
- router.push("/appointments");
- }
- }, 100);
- });
- jitsiApi.current.addEventListener("readyToClose", () => {
- setTimeout(() => {
- if (isLeavingIntentionally.current) {
- router.push("/appointments");
- }
- }, 100);
- });
- }, [session, jitsiRoomName, jitsiDomain, jitsiToken, useJWT, router]);
- const handleExitClick = () => {
- setShowExitDialog(true);
- };
- const handleConfirmExit = () => {
- isLeavingIntentionally.current = true;
- if (jitsiApi.current) {
- jitsiApi.current.dispose();
- jitsiApi.current = null;
- }
- isInitialized.current = false;
- router.push("/appointments");
- };
- const handleCancelExit = () => {
- setShowExitDialog(false);
- };
- // Interceptar cierre de pestaña o navegación
- useEffect(() => {
- const handleBeforeUnload = (e: BeforeUnloadEvent) => {
- // Solo mostrar advertencia si NO es una salida intencional
- if (!isLeavingIntentionally.current) {
- e.preventDefault();
- e.returnValue = "¿Estás seguro de que quieres salir de la videollamada?";
- return e.returnValue;
- }
- };
- window.addEventListener("beforeunload", handleBeforeUnload);
- return () => {
- window.removeEventListener("beforeunload", handleBeforeUnload);
- };
- }, []);
- useEffect(() => {
- if (status === "loading" || !session || !jitsiContainer.current || isInitialized.current || !jitsiDomain || !jitsiRoomName) return;
- // Construir la URL del script usando el dominio configurado
- const scriptSrc = `https://${jitsiDomain}/external_api.js`;
- // Verificar si el script ya está cargado
- const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
- if (existingScript) {
- // Si el script ya existe y window.JitsiMeetExternalAPI está disponible, inicializar directamente
- if (window.JitsiMeetExternalAPI) {
- initJitsi();
- }
- return;
- }
- // Cargar Jitsi script desde el dominio configurado
- const script = document.createElement("script");
- script.src = scriptSrc;
- script.async = true;
- script.onload = () => initJitsi();
- script.onerror = () => {
- console.error(`Error al cargar el script de Jitsi desde ${scriptSrc}`);
- setAccessDenied(true);
- setDenialReason("No se pudo conectar con el servidor de videollamadas");
- };
- document.body.appendChild(script);
- return () => {
- if (jitsiApi.current) {
- jitsiApi.current.dispose();
- jitsiApi.current = null;
- }
- isInitialized.current = false;
- // No eliminar el script aquí para evitar conflictos
- };
- }, [status, session, jitsiDomain, jitsiRoomName, initJitsi]);
- if (status === "loading" || loading) {
- return (
- <div className="flex items-center justify-center min-h-screen">
- <Loader2 className="h-8 w-8 animate-spin" />
- </div>
- );
- }
- if (!session) {
- redirect("/auth/login");
- }
- // Mostrar pantalla de acceso denegado si no cumple las condiciones
- if (accessDenied) {
- return (
- <div className="container mx-auto px-4 py-8 max-w-2xl">
- <Card>
- <CardHeader>
- <div className="flex items-center gap-2 text-destructive">
- <XCircle className="h-6 w-6" />
- <CardTitle>Acceso no permitido</CardTitle>
- </div>
- </CardHeader>
- <CardContent className="space-y-4">
- <p className="text-muted-foreground">{denialReason}</p>
-
- {appointment?.fechaSolicitada && (
- <div className="bg-muted p-4 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <Clock className="h-5 w-5" />
- <p className="font-medium">Estado de la cita</p>
- </div>
- <p className="text-sm text-muted-foreground">
- {getAppointmentTimeStatus(appointment.fechaSolicitada)}
- </p>
- </div>
- )}
-
- <div className="flex gap-2">
- <Button onClick={() => router.push(`/appointments/${params.id}`)}>
- Ver detalles de la cita
- </Button>
- <Button variant="outline" onClick={() => router.push("/appointments")}>
- Volver a mis citas
- </Button>
- </div>
- </CardContent>
- </Card>
- </div>
- );
- }
- const isDoctor = session.user.role === "DOCTOR";
- const appointmentId = params.id as string;
- return (
- <>
- <div className="container mx-auto px-4 py-8 max-w-7xl">
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* Videollamada - 2 columnas en pantallas grandes */}
- <div className="lg:col-span-2">
- <Card>
- <CardHeader>
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <Video className="h-6 w-6" />
- <CardTitle>Consulta Telemática</CardTitle>
- </div>
- <div className="flex items-center gap-2">
- {appointment?.record && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setShowRecordsModal(true)}
- >
- <FileText className="h-4 w-4 mr-2" />
- Ver Reporte
- </Button>
- )}
- <Button variant="outline" onClick={handleExitClick}>
- Salir
- </Button>
- </div>
- </div>
- </CardHeader>
- <CardContent>
- <div ref={jitsiContainer} className="w-full rounded-lg overflow-hidden bg-muted" />
- </CardContent>
- </Card>
- </div>
- {/* Notas de consulta - 1 columna en pantallas grandes */}
- <div className="lg:col-span-1">
- <ConsultationNotes appointmentId={appointmentId} isDoctor={isDoctor} />
- </div>
- </div>
- </div>
- {/* Records Modal */}
- <RecordsModal
- isOpen={showRecordsModal}
- record={appointment?.record as MedicalRecord || null}
- generatingPDF={false}
- onClose={() => setShowRecordsModal(false)}
- onCopyContent={handleCopyContent}
- onDownloadReport={handleDownloadReport}
- onGeneratePDF={handleGeneratePDF}
- />
- {/* Modal de confirmación de salida */}
- <Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
- <DialogContent>
- <DialogHeader>
- <div className="flex items-center gap-2">
- <AlertTriangle className="h-5 w-5 text-destructive" />
- <DialogTitle>¿Salir de la videollamada?</DialogTitle>
- </div>
- <DialogDescription>
- Si sales ahora, la videollamada se cerrará. {isDoctor && "Asegúrate de haber guardado las notas de consulta si las tienes."}
- </DialogDescription>
- </DialogHeader>
- <DialogFooter>
- <Button variant="outline" onClick={handleCancelExit}>
- Cancelar
- </Button>
- <Button variant="destructive" onClick={handleConfirmExit}>
- Salir de la llamada
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
- }
|